Skip to content

Isolate golangci-lint cache per worktree to stop cross-worktree contamination#5925

Open
janniklasrose wants to merge 2 commits into
mainfrom
janniklasrose/lint-cache-per-worktree
Open

Isolate golangci-lint cache per worktree to stop cross-worktree contamination#5925
janniklasrose wants to merge 2 commits into
mainfrom
janniklasrose/lint-cache-per-worktree

Conversation

@janniklasrose

@janniklasrose janniklasrose commented Jul 14, 2026

Copy link
Copy Markdown
Contributor

Problem

Running lint from a worktree under ~/pub/cli.worktrees/* intermittently fails with phantom lint issues pointing at file paths in other worktrees — including deleted ones (failed to parse file … no such file or directory).

golangci-lint keys its results cache on file contents but stores each cached issue's absolute path. Two worktrees with byte-identical packages (the near-static tools/ module is the usual culprit) get a cross-worktree cache hit in the machine-global ~/Library/Caches/golangci-lint, so the second worktree inherits the first's absolute paths. The //nolint post-processor then re-opens those paths to apply suppressions; when the other worktree has been deleted the open fails, so suppressed issues (e.g. dupword) leak through as phantom failures.

Upstream: this is golangci/golangci-lint#6656, closed as an accepted trade-off (won't-fix, no opt-out flag shipped) of the shared-cache work in #6445 ("decrease cache entropy", which dropped the path from the cache key in v2.12.0). The maintainer's position: "the core of linters is required absolute paths." A commenter reproduced the exact //nolint-leak variant. #6445 also notes concurrent cross-worktree runs on the shared cache are unsafe (file races).

Fix

Set GOLANGCI_LINT_CACHE to a per-worktree path ({{.ROOT_DIR}}/.task/golangci-lint) in a top-level env: block, so the cache lives under each worktree's already-gitignored .task/. No two worktrees can share it → contamination is structurally impossible.

Verified with disposable worktrees (create A → lint → delete A → lint identical B): 3 phantom dupword failures before, 0 issues after. CI is unaffected: the lint job runs a single clean checkout and never persists this directory (setup-go caches only the Go build cache).

Performance trade-off (measured)

Isolation removes cross-worktree cache reuse, so a fresh worktree's first full lint runs cold. Measured on the root module with the shared go-build cache kept warm (only golangci-lint's own issues cache is affected):

Scenario Time
task lint warm (steady state) ~20s
task lint cold golangci, warm go-build (fresh worktree, our change) ~100–146s
task lint fresh worktree reusing shared cache (today's default) ~14s
task lint-q (incremental, the default ./task dev loop) cold or warm <0.5s

So the penalty only hits full task lint / full / all. The everyday ./task loop (lint-q, changed files only) is unaffected.

Two mitigations for developers with many worktrees:

  • Opt-out: Task's env: yields to an inherited value, so export GOLANGCI_LINT_CACHE=~/Library/Caches/golangci-lint restores cross-worktree reuse with no Taskfile edit.
  • task seed-lint-cache: copies the main worktree's cache into the current one so the first full lint runs warm (~4s in testing). Safe because the main worktree is permanent, so its cached issue paths always resolve; no-ops when GOLANGCI_LINT_CACHE is overridden.

Testing

Reproduced the failure and confirmed the fix, the opt-out precedence, and the seed task end-to-end using disposable detached worktrees. yamlfmt -lint clean.

This pull request and its description were written by Isaac.

…mination

golangci-lint keys its results cache on file *contents* but stores each
cached issue's *absolute* path. Two worktrees with byte-identical packages
(the near-static tools/ module is the usual culprit) get a cross-worktree
cache hit, so the second inherits the first's absolute paths. The //nolint
processor then re-opens those paths to apply suppressions; when the other
worktree has been deleted the open fails, so suppressed issues (e.g.
dupword) leak through as phantom failures pointing at a gone path, plus a
wall of "no such file or directory" warnings.

The content-addressable cache keys added in v2.12.0 only fixed the
concurrent case (both worktrees live). The deleted-worktree case still
reproduces on the pinned v2.12.2. Rooting GOLANGCI_LINT_CACHE under the
gitignored per-worktree .task/ makes contamination structurally impossible.

CI is unaffected: it runs a single checkout and never persists this
directory (setup-go caches only the Go build cache).

Co-authored-by: Isaac
@janniklasrose janniklasrose requested a review from pietern July 14, 2026 15:02
@github-actions

Copy link
Copy Markdown
Contributor

Waiting for approval

Based on git history, these people are best suited to review:

  • @pietern -- recent work in ./

Eligible reviewers: @andrewnester, @anton-107, @denik, @lennartkats-db, @renaudhartert-db, @shreyas-goenka, @simonfaltum

Suggestions based on git history. See OWNERS for ownership rules.

@eng-dev-ecosystem-bot

eng-dev-ecosystem-bot commented Jul 14, 2026

Copy link
Copy Markdown
Collaborator

Integration test report

Commit: 4085d52

Run: 29488390710

Env 🟨​KNOWN 💚​RECOVERED 🙈​SKIP ✅​pass 🙈​skip Time
💚​ aws linux 4 4 227 1105 4:48
💚​ aws windows 4 4 229 1103 8:48
💚​ aws-ucws linux 4 4 316 1021 7:43
💚​ aws-ucws windows 4 4 318 1019 9:42
💚​ azure linux 4 4 227 1104 5:12
💚​ azure windows 4 4 229 1102 8:48
💚​ azure-ucws linux 4 4 318 1018 7:55
🟨​ azure-ucws windows 1 1 4 320 1016 12:41
💚​ gcp linux 4 4 226 1106 5:00
💚​ gcp windows 4 4 228 1104 8:57
8 interesting tests: 4 SKIP, 3 RECOVERED, 1 KNOWN
Test Name aws linux aws windows aws-ucws linux aws-ucws windows azure linux azure windows azure-ucws linux azure-ucws windows gcp linux gcp windows
💚​ TestAccept 💚​R 💚​R 💚​R 💚​R 💚​R 💚​R 💚​R 💚​R 💚​R 💚​R
🙈​ TestAccept/bundle/invariant/no_drift 🙈​S 🙈​S 🙈​S 🙈​S 🙈​S 🙈​S 🙈​S 🙈​S 🙈​S 🙈​S
🙈​ TestAccept/bundle/resources/vector_search_endpoints/drift/recreated_same_name 🙈​S 🙈​S 🙈​S 🙈​S 🙈​S 🙈​S 🙈​S 🙈​S 🙈​S 🙈​S
🙈​ TestAccept/bundle/resources/vector_search_indexes/recreate/embedding_dimension 🙈​S 🙈​S 🙈​S 🙈​S 🙈​S 🙈​S 🙈​S 🙈​S 🙈​S 🙈​S
🙈​ TestAccept/ssh/connection 🙈​S 🙈​S 🙈​S 🙈​S 🙈​S 🙈​S 🙈​S 🙈​S 🙈​S 🙈​S
🟨​ TestFetchRepositoryInfoAPI_FromRepo 💚​R 💚​R 💚​R 💚​R 💚​R 💚​R 💚​R 🟨​K 💚​R 💚​R
💚​ TestFetchRepositoryInfoAPI_FromRepo/root 💚​R 💚​R 💚​R 💚​R 💚​R 💚​R 💚​R 💚​R 💚​R
💚​ TestFetchRepositoryInfoAPI_FromRepo/subdir 💚​R 💚​R 💚​R 💚​R 💚​R 💚​R 💚​R 💚​R 💚​R
Top 10 slowest tests (at least 2 minutes):
duration env testname
8:45 azure-ucws windows TestAccept
8:38 aws-ucws windows TestAccept
8:33 gcp windows TestAccept
8:23 azure windows TestAccept
8:21 aws windows TestAccept
2:57 aws linux TestAccept
2:57 gcp linux TestAccept
2:50 azure linux TestAccept
2:48 aws-ucws linux TestAccept
2:48 azure-ucws linux TestAccept

Per-worktree GOLANGCI_LINT_CACHE isolation removes cross-worktree cache
reuse, so a fresh worktree's first full `task lint` runs cold (~2min on the
root module vs ~20s warm; the incremental `task lint-q` in the default dev
loop is unaffected, <1s either way).

Two mitigations for developers with many worktrees:

- Opt-out: Task's `env:` yields to an inherited value, so exporting
  GOLANGCI_LINT_CACHE (e.g. to the shared ~/Library/Caches/golangci-lint)
  restores cross-worktree reuse without editing the Taskfile.
- `task seed-lint-cache`: copies the main worktree's cache into the current
  one so the first full lint runs warm (~4s in testing). Safe because the
  main worktree is permanent, so its cached issue paths always resolve; the
  task no-ops when GOLANGCI_LINT_CACHE has been overridden.

Also link golangci/golangci-lint#6656, where the maintainer confirmed the
shared-cache path contamination is an accepted trade-off of #6445.

Co-authored-by: Isaac
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants